// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:io';

import 'package:ffigen/src/code_generator.dart';
import 'package:ffigen/src/code_generator/scope.dart';
import 'package:ffigen/src/code_generator/utils.dart';
import 'package:ffigen/src/config_provider/config.dart';
import 'package:ffigen/src/config_provider/utils.dart';
import 'package:ffigen/src/config_provider/yaml_config.dart';
import 'package:ffigen/src/context.dart';
import 'package:ffigen/src/visitor/ast.dart';
import 'package:logging/logging.dart';
import 'package:package_config/package_config_types.dart';
import 'package:path/path.dart' as path;
import 'package:test/test.dart';
import 'package:yaml/yaml.dart' as yaml;

export 'package:ffigen/src/config_provider/utils.dart';

Context testContext([FfiGenerator? generator]) => Context(
  createTestLogger(),
  generator ?? FfiGenerator(output: Output(dartFile: Uri.file('unused'))),
);

Logger createTestLogger({
  List<String>? capturedMessages,
  Level level = Level.ALL,
}) => Logger.detached('')
  ..level = level
  ..onRecord.listen((record) {
    printOnFailure('${record.level.name}: ${record.time}: ${record.message}');
    capturedMessages?.add(record.message);
  });

extension LibraryTestExt on Library {
  /// Get a [Binding]'s generated string with a given name.
  String getBindingAsString(String name) =>
      getBinding(name).toBindingString(writer).string;

  /// Get a [Binding] with a given name.
  Binding getBinding(String name) {
    try {
      final b = bindings.firstWhere((element) => element.name == name);
      return b;
    } catch (e) {
      throw NotFoundException("Binding '$name' not found.");
    }
  }

  /// Runs a fake version of the Symbol renaming step that usually happens
  /// during the transformation pipeline. The Symbol names aren't actually
  /// renamed to avoid collisions, the name is just filled from oldName without
  /// regard for the other names in the namespace. This lets us access the names
  /// for testing, without running whole transformation pipeline.
  void forceFillNamesForTesting() {
    visit(context, _FakeRenamer(), bindings);
    context.extraSymbols = (
      wrapperClassName: Symbol('NativeLibrary', SymbolKind.klass)
        ..forceFillForTesting(),
      lookupFuncName: Symbol('_lookup', SymbolKind.field)
        ..forceFillForTesting(),
      symbolAddressVariableName: Symbol('addresses', SymbolKind.field)
        ..forceFillForTesting(),
    );
    context.libs.forceFillForTesting();
    context.rootScope.fillNames();
    context.rootObjCScope.fillNames();
  }
}

class _FakeRenamer extends Visitation {
  @override
  void visitSymbol(Symbol node) => node.forceFillForTesting();

  @override
  void visitBinding(Binding node) {
    if (node is HasLocalScope) {
      (node as HasLocalScope).localScope = Scope.createRoot('test')
        ..fillNames();
    }
    node.visitChildren(visitor);
  }
}

/// Check whether a file generated by test/setup.dart exists and throw a helpful
/// exception if it does not.
void verifySetupFile(File file) {
  if (!file.existsSync()) {
    throw NotFoundException(
      'The file ${file.path} does not exist.\n\n'
      'You may need to run: dart run test/setup.dart\n',
    );
  }
}

// Remove '\r' for Windows compatibility, then apply user's normalizer.
String _normalizeGeneratedCode(
  String generated,
  String Function(String)? codeNormalizer,
) {
  final noCR = generated.replaceAll('\r', '');
  if (codeNormalizer == null) return noCR;
  return codeNormalizer(noCR);
}

/// Generates actual file using library and tests using [expect] with expected.
///
/// This will not delete the actual debug file incase [expect] throws an error.
void matchLibraryWithExpected(
  Library library,
  String pathForActual,
  List<String> pathToExpected, {
  String Function(String)? codeNormalizer,
  bool format = true,
}) {
  _matchFileWithExpected(
    library: library,
    pathForActual: pathForActual,
    pathToExpected: pathToExpected,
    fileWriter: ({required Library library, required File file}) =>
        library.generateFile(file, format: format),
    codeNormalizer: codeNormalizer,
  );
}

/// Generates actual file using library and tests using [expect] with expected.
///
/// This will not delete the actual debug file incase [expect] throws an error.
void matchLibrarySymbolFileWithExpected(
  Library library,
  String pathForActual,
  List<String> pathToExpected,
  String importPath,
) {
  _matchFileWithExpected(
    library: library,
    pathForActual: pathForActual,
    pathToExpected: pathToExpected,
    fileWriter: ({required Library library, required File file}) {
      if (!library.writer.canGenerateSymbolOutput) library.generate();
      library.generateSymbolOutputFile(file, importPath);
    },
  );
}

const bool updateExpectations = false;

/// Transforms a repo relative path to an absolute path.
String absPath(String p) => path.join(packagePathForTests, p);

/// Returns a path to a config yaml in a unit test.
String configPath(String directory, String file) =>
    absPath(configPathForTest(directory, file));

/// Returns the temp directory used to store bindings generated by tests.
String tmpDir = path.join(packagePathForTests, 'test', '.temp');

/// Generates actual file using library and tests using [expect] with expected.
///
/// This will not delete the actual debug file incase [expect] throws an error.
void _matchFileWithExpected({
  required Library library,
  required String pathForActual,
  required List<String> pathToExpected,
  required void Function({required Library library, required File file})
  fileWriter,
  String Function(String)? codeNormalizer,
}) {
  final expectedPath = path.joinAll([packagePathForTests, ...pathToExpected]);
  final file = File(path.join(tmpDir, pathForActual));
  fileWriter(library: library, file: file);
  try {
    final actual = _normalizeGeneratedCode(
      file.readAsStringSync(),
      codeNormalizer,
    );
    final expected = _normalizeGeneratedCode(
      File(expectedPath).readAsStringSync(),
      codeNormalizer,
    );
    expect(actual.split('\n'), expected.split('\n'));
    _expectNoAnalysisErrors(expectedPath);
    if (file.existsSync()) {
      file.delete();
    }
  } catch (e) {
    print('Failed test: Debug generated file: ${file.absolute.path}');
    if (updateExpectations) {
      print('Updating expectations. Check the diffs!');
      file.copySync(expectedPath);
    }
    rethrow;
  }
}

void _expectNoAnalysisErrors(String file) {
  Process.runSync(dartExecutable, [
    'pub',
    'get',
  ], workingDirectory: path.dirname(file));
  final result = Process.runSync(dartExecutable, [
    'analyze',
    file,
  ], workingDirectory: path.dirname(file));
  if (result.exitCode != 0) print(result.stdout);
  expect(result.exitCode, 0);
}

class NotFoundException implements Exception {
  final String message;
  NotFoundException(this.message);

  @override
  String toString() {
    return message;
  }
}

FfiGenerator testConfig(String yamlBody, {String? filename, Logger? logger}) {
  return YamlConfig.fromYaml(
    yaml.loadYaml(yamlBody) as yaml.YamlMap,
    logger ?? createTestLogger(),
    filename: filename,
    packageConfig: PackageConfig([
      Package(
        'shared_bindings',
        Uri.file(
          path.join(packagePathForTests, 'example', 'shared_bindings', 'lib/'),
        ),
      ),
    ]),
  ).configAdapter();
}

FfiGenerator testConfigFromPath(String path) {
  final file = File(path);
  final yamlBody = file.readAsStringSync();
  return testConfig(yamlBody, filename: path);
}

bool isFlutterTester = Platform.resolvedExecutable.contains('flutter_tester');
